<?php


namespace SleekDB\Classes;


use SleekDB\Exceptions\InvalidArgumentException;
use DateTime;
use Exception;
use Throwable;

/**
 * Class ConditionsHandler
 * Handle all types of conditions to check if a document has passed.
 */
class ConditionsHandler
{

  /**
   * Get the result of an condition.
   * @param string $condition
   * @param mixed $fieldValue value of current field
   * @param mixed $value value to check
   * @return bool
   * @throws InvalidArgumentException
   */
  public static function verifyCondition(string $condition, $fieldValue, $value): bool
  {

    if($value instanceof DateTime){
      // compare timestamps

      // null, false or an empty string will convert to current date and time.
      // That is not what we want.
      if(empty($fieldValue)){
        return false;
      }
      $value = $value->getTimestamp();
      $fieldValue = self::convertValueToTimeStamp($fieldValue);
    }

    $condition = strtolower(trim($condition));
    switch ($condition){
      case "=":
      case "===":
        return ($fieldValue === $value);
      case "==":
        return ($fieldValue == $value);
      case "<>":
        return ($fieldValue != $value);
      case "!==":
      case "!=":
        return ($fieldValue !== $value);
      case ">":
        return ($fieldValue > $value);
      case ">=":
        return ($fieldValue >= $value);
      case "<":
        return ($fieldValue < $value);
      case "<=":
        return ($fieldValue <= $value);
      case "not like":
      case "like":

        if(!is_string($value)){
          throw new InvalidArgumentException("When using \"LIKE\" or \"NOT LIKE\" the value has to be a string.");
        }

        // escape characters that are part of regular expression syntax
        // https://www.php.net/manual/en/function.preg-quote.php
        // We can not use preg_quote because the following characters are also wildcard characters in sql
        // so we will not escape them: [ ^ ] -
        $charactersToEscape = array(
            '\\' => '\\\\', // Escape backslash to prevent unwanted behaviour
            '/' => '\/', // slash needs to be escaped because it's used as delimiter
            '.' => '\.',
            '+' => '\+',
            '*' => '\*',
            '?' => '\?',
            '$' => '\$',
            '(' => '\(',
            ')' => '\)',
            '{' => '\{',
            '}' => '\}',
            '=' => '\=',
            '!' => '\!',
            '<' => '\<',
            '>' => '\>',
            '|' => '\|',
            ':' => '\:',
            '#' => '\#',
            '%' => '.*',
            '_' => '.{1}',
            // Allow escaping of % and _ with backslash
            '\.*' => '%',
            '\.{1}' => '_',
            // Allow escaping of wildcards
            '\\\\[' => '\[',
            '\\\\^' => '\^',
            '\\\\]' => '\]',
            '\\\\-' => '\-',
        );
        $value = str_replace(array_keys($charactersToEscape), array_values($charactersToEscape), $value); // (zero or more characters) and (single character)
        $pattern = '/^' . $value . '$/i';
        $result = (preg_match($pattern, $fieldValue) === 1);
        return ($condition === 'not like') ? !$result : $result;

      case "not in":
      case "in":
        if(!is_array($value)){
          $value = (!is_object($value) && !is_array($value) && !is_null($value)) ? $value : gettype($value);
          throw new InvalidArgumentException("When using \"in\" and \"not in\" you have to check against an array. Got: $value");
        }
        if(!empty($value)){
          (list($firstElement) = $value);
          if($firstElement instanceof DateTime){
            // if the user wants to use DateTime, every element of the array has to be an DateTime object.

            // compare timestamps

            // null, false or an empty string will convert to current date and time.
            // That is not what we want.
            if(empty($fieldValue)){
              return false;
            }

            foreach ($value as $key => $item){
              if(!($item instanceof DateTime)){
                throw new InvalidArgumentException("If one DateTime object is given in an \"IN\" or \"NOT IN\" comparison, every element has to be a DateTime object!");
              }
              $value[$key] = $item->getTimestamp();
            }

            $fieldValue = self::convertValueToTimeStamp($fieldValue);
          }
        }
        $result = in_array($fieldValue, $value, true);
        return ($condition === "not in") ? !$result : $result;
      case "not between":
      case "between":

        if(!is_array($value) || ($valueLength = count($value)) !== 2){
          $value = (!is_object($value) && !is_array($value) && !is_null($value)) ? $value : gettype($value);
          if(isset($valueLength)){
            $value .= " | Length: $valueLength";
          }
          throw new InvalidArgumentException("When using \"between\" you have to check against an array with a length of 2. Got: $value");
        }

        list($startValue, $endValue) = $value;

        $result = (
          self::verifyCondition(">=", $fieldValue, $startValue)
          && self::verifyCondition("<=", $fieldValue, $endValue)
        );

        return ($condition === "not between") ? !$result : $result;
      case "not contains":
      case "contains":

        if(!is_array($fieldValue)){
          return ($condition === "not contains");
        }

        $fieldValues = [];

        if($value instanceof DateTime){
          // compare timestamps
          $value = $value->getTimestamp();

          foreach ($fieldValue as $item){
            // null, false or an empty string will convert to current date and time.
            // That is not what we want.
            if(empty($item)){
              continue;
            }
            try{
              $fieldValues[] = self::convertValueToTimeStamp($item);
            } catch (Exception $exception){
            }
          }
        }

        if(!empty($fieldValues)){
          $result = in_array($value, $fieldValues, true);
        } else {
          $result = in_array($value, $fieldValue, true);
        }

        return ($condition === "not contains") ? !$result : $result;
      case 'exists':
        return $fieldValue === $value;
      default:
        throw new InvalidArgumentException("Condition \"$condition\" is not allowed.");
    }
  }

  /**
   * @param array $element condition or operation
   * @param array $data
   * @return bool
   * @throws InvalidArgumentException
   */
  public static function handleWhereConditions(array $element, array $data): bool
  {
    if(empty($element)){
      throw new InvalidArgumentException("Malformed where statement! Where statements can not contain empty arrays.");
    }
    if(array_keys($element) !== range(0, (count($element) - 1))){
      throw new InvalidArgumentException("Malformed where statement! Associative arrays are not allowed.");
    }
    // element is a where condition
    if(is_string($element[0]) && is_string($element[1])){
      if(count($element) !== 3){
        throw new InvalidArgumentException("Where conditions have to be [fieldName, condition, value]");
      }

      $fieldName = $element[0];
      $condition = strtolower(trim($element[1]));
      $fieldValue = ($condition === 'exists')
        ? NestedHelper::nestedFieldExists($fieldName, $data)
        : NestedHelper::getNestedValue($fieldName, $data);

      return self::verifyCondition($condition, $fieldValue, $element[2]);
    }

    // element is an array "brackets"

    // prepare results array - example: [true, "and", false]
    $results = [];
    foreach ($element as $value){
      if(is_array($value)){
        $results[] = self::handleWhereConditions($value, $data);
      } else if (is_string($value)) {
        $results[] = $value;
      } else if($value instanceof \Closure){
        $result = $value($data);
        if(!is_bool($result)){
          $resultType = gettype($result);
          $errorMsg = "The closure in the where condition needs to return a boolean. Got: $resultType";
          throw new InvalidArgumentException($errorMsg);
        }
        $results[] = $result;
      } else {
        $value = (!is_object($value) && !is_array($value) && !is_null($value)) ? $value : gettype($value);
        throw new InvalidArgumentException("Invalid nested where statement element! Expected condition or operation, got: \"$value\"");
      }
    }

    // first result as default value
    $returnValue = array_shift($results);

    if(is_bool($returnValue) === false){
      throw new InvalidArgumentException("Malformed where statement! First part of the statement have to be a condition.");
    }

    // used to prioritize the "and" operation.
    $orResults = [];

    // use results array to get the return value of the conditions within the bracket
    while(!empty($results) || !empty($orResults)){

      if(empty($results)) {
        if($returnValue === true){
          // we need to check anymore, because the result of true || false is true
          break;
        }
        // $orResults is not empty.
        $nextResult = array_shift($orResults);
        $returnValue = $returnValue || $nextResult;
        continue;
      }

      $operationOrNextResult = array_shift($results);

      if(is_string($operationOrNextResult)){
        $operation = $operationOrNextResult;

        if(empty($results)){
          throw new InvalidArgumentException("Malformed where statement! Last part of a condition can not be a operation.");
        }
        $nextResult = array_shift($results);

        if(!is_bool($nextResult)){
          throw new InvalidArgumentException("Malformed where statement! Two operations in a row are not allowed.");
        }
      } else if(is_bool($operationOrNextResult)){
        $operation = "AND";
        $nextResult = $operationOrNextResult;
      } else {
        throw new InvalidArgumentException("Malformed where statement! A where statement have to contain just operations and conditions.");
      }

      if(!in_array(strtolower($operation), ["and", "or"])){
        $operation = (!is_object($operation) && !is_array($operation) && !is_null($operation)) ? $operation : gettype($operation);
        throw new InvalidArgumentException("Expected 'and' or 'or' operator got \"$operation\"");
      }

      // prepare $orResults execute after all "and" are done.
      if(strtolower($operation) === "or"){
        $orResults[] = $returnValue;
        $returnValue = $nextResult;
        continue;
      }

      $returnValue = $returnValue && $nextResult;

    }

    return $returnValue;
  }

  /**
   * @param array $results
   * @param array $currentDocument
   * @param array $distinctFields
   * @return bool
   */
  public static function handleDistinct(array $results, array $currentDocument, array $distinctFields): bool
  {
    // Distinct data check.
    foreach ($results as $result) {
      foreach ($distinctFields as $field) {
        try {
          $storePassed = (NestedHelper::getNestedValue($field, $result) !== NestedHelper::getNestedValue($field, $currentDocument));
        } catch (Throwable $th) {
          continue;
        }
        if ($storePassed === false) {
          return false;
        }
      }
    }

    return true;
  }

  /**
   * @param array $data
   * @param bool $storePassed
   * @param array $nestedWhereConditions
   * @return bool
   * @throws InvalidArgumentException
   * @deprecated since version 2.3, use handleWhereConditions instead.
   */
  public static function handleNestedWhere(array $data, bool $storePassed, array $nestedWhereConditions): bool
  {
    // TODO remove nested where with v3.0

    if(empty($nestedWhereConditions)){
      return $storePassed;
    }

    // the outermost operation specify how the given conditions are connected with other conditions,
    // like the ones that are specified using the where, orWhere, in or notIn methods
    $outerMostOperation = (array_keys($nestedWhereConditions))[0];
    $nestedConditions = $nestedWhereConditions[$outerMostOperation];

    // specifying outermost is optional and defaults to "and"
    $outerMostOperation = (is_string($outerMostOperation)) ? strtolower($outerMostOperation) : "and";

    // if the document already passed the store with another condition, we dont need to check it.
    if($outerMostOperation === "or" && $storePassed === true){
      return true;
    }

    return self::_nestedWhereHelper($nestedConditions, $data);
  }

  /**
   * @param array $element
   * @param array $data
   * @return bool
   * @throws InvalidArgumentException
   * @deprecated since version 2.3. use _handleWhere instead
   */
  private static function _nestedWhereHelper(array $element, array $data): bool
  {
    // TODO remove nested where with v3.0
    // element is a where condition
    if(array_keys($element) === range(0, (count($element) - 1)) && is_string($element[0])){
      if(count($element) !== 3){
        throw new InvalidArgumentException("Where conditions have to be [fieldName, condition, value]");
      }

      $fieldValue = NestedHelper::getNestedValue($element[0], $data);

      return self::verifyCondition($element[1], $fieldValue, $element[2]);
    }

    // element is an array "brackets"

    // prepare results array - example: [true, "and", false]
    $results = [];
    foreach ($element as $value){
      if(is_array($value)){
        $results[] = self::_nestedWhereHelper($value, $data);
      } else if (is_string($value)){
        $results[] = $value;
      } else {
        $value = (!is_object($value) && !is_array($value)) ? $value : gettype($value);
        throw new InvalidArgumentException("Invalid nested where statement element! Expected condition or operation, got: \"$value\"");
      }
    }

    if(count($results) < 3){
      throw new InvalidArgumentException("Malformed nested where statement! A condition consists of at least 3 elements.");
    }

    // first result as default value
    $returnValue = array_shift($results);

    // use results array to get the return value of the conditions within the bracket
    while(!empty($results)){
      $operation = array_shift($results);
      $nextResult = array_shift($results);

      if(((count($results) % 2) !== 0)){
        throw new InvalidArgumentException("Malformed nested where statement!");
      }

      if(!is_string($operation) || !in_array(strtolower($operation), ["and", "or"])){
        $operation = (!is_object($operation) && !is_array($operation)) ? $operation : gettype($operation);
        throw new InvalidArgumentException("Expected 'and' or 'or' operator got \"$operation\"");
      }

      if(strtolower($operation) === "and"){
        $returnValue = $returnValue && $nextResult;
      } else {
        $returnValue = $returnValue || $nextResult;
      }
    }

    return $returnValue;
  }

  /**
   * @param $value
   * @return int
   * @throws InvalidArgumentException
   */
  private static function convertValueToTimeStamp($value): int
  {
    $value = (is_string($value)) ? trim($value) : $value;
    try{
      return (new DateTime($value))->getTimestamp();
    } catch (Exception $exception){
      $value = (!is_object($value) && !is_array($value))
        ? $value
        : gettype($value);
      throw new InvalidArgumentException(
        "DateTime object given as value to check against. "
        . "Could not convert value into DateTime. "
        . "Value: $value"
      );
    }
  }
}